feat(spec,driver-sql,cli): one physical representation for the NUMERIC column family, read by all three producers - #16887
Conversation
The container restart that killed the previous os-dev left this in the worktree, uncommitted. Committed verbatim so it is not the only copy; every number in it is re-measured before anything is claimed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
… for the NUMERIC column family Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
…e producers Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
…(ADR-0113) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
…c column table Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
📓 Docs Drift CheckThis PR changes 4 package(s): 10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e24f7f55664d7804b431f94b47735ebfccc65017 && git checkout e24f7f55664d7804b431f94b47735ebfccc65017
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f6b7c53db7b65bbfb019750efb4e545470b0c2b7 c5c2182f7643e1cb03969d546148ccc0aaeeee3e && git checkout -B drift-repro f6b7c53db7b65bbfb019750efb4e545470b0c2b7 && git merge --no-ff c5c2182f7643e1cb03969d546148ccc0aaeeee3e
node scripts/docs-audit/affected-docs.mjs --json f6b7c53db7b65bbfb019750efb4e545470b0c2b7
|
Contract review (
|
Brings in #16890 (f2b5e46), which withdrew the ADR-0087 field-required-notnull-explicit conversion and rewrote the ADR-0113 comment block this branch quotes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018rzQyhLGC5iVs11V3TzRs5
…ve-dialect pins and the read-path bound The protocol doc stated the opposite of the DDL this branch emits, and three claims the branch makes had no instrument behind them. - content/docs/protocol/objectql/types.mdx: `number`, `currency` and `percent` said "a floating-point column (REAL / FLOAT)" and the type-conversion matrix said `REAL` / `FLOAT` / `REAL` for the family and for `summary`. All of it now states the exact-decimal column, the "new columns only" bound, and the SQLite and per-dialect `rating` consequences, with `slider` / `progress` / `rating` given rows the matrix never carried. - packages/spec/src/api/sortability.zod.ts (and its generated content/docs/references/api/sortability.mdx): the `summary` fossil said "an engine-maintained `table.float`". - packages/services/service-analytics/src/measure-result-type.ts: the same fossil, as the reason `summary` needs no correction. The reason is unchanged; only the column it names moved. New live-dialect cell (`sql-driver-16318-numeric-representation-live-dialects.test.ts`): `numeric_precision` / `numeric_scale` read off the server's own catalog and compared against the spec table, the `typeof === 'number'` read that SQLite cannot exercise, and `rating`'s fractional disposition asserted PER DIALECT — PostgreSQL refuses, MySQL rounds. The PG half was executed against a live PostgreSQL 16.13; the MySQL half runs in the "Temporal Conformance (live PG + MySQL)" job. `formatOutput`'s numeric read coercion is scoped per dialect. It reads the authorable `NUMERIC_VALUE_TYPES` half on the server dialects and keeps the wider `NUMERIC_SCALAR_TYPES` set on SQLite, where the legacy TEXT-affinity repair lives. The aliases `integer` / `int` / `float` are how an external, introspected column reaches the driver, and node-postgres hands `bigint` back as a string precisely because it does not fit a JS double: coercing it would round above 2^53 on a table this change never created, outside the "new columns only" bound. Changeset: `rating`'s two dialects stated separately, the residual bound restated as "30 fractional digits are kept", the binary64 read seam named, and the ADR-0087 disposition argued positively instead of resting on the detector miss the gate's own docblock warns about. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018rzQyhLGC5iVs11V3TzRs5
|
Director seat adoption record — summon #20, Contract review (
|
…the ADR-0087 marker at the right bullet
F1 — `packages/spec/src/data/numeric-column-representation.ts`'s "residual
bound" section said only that magnitudes below 1e-30 round to zero, while the
shipped changeset states the column keeps 30 fractional digits, that precision
loss BEGINS around |x| < 1e-13 and is TOTAL below 1e-30, and that magnitudes at
or above 1e35 are refused. That docblock is the canonical home both
`sql-driver.ts` and `os generate migration` point readers at ("the spec module
carries the measurements, the ruling and the residual bound"), and it says of
itself that the bound is "stated here so no reader has to rediscover it" — so
it now states both ends of the bound in the changeset's own terms.
F2 — `.changeset/numeric-column-representation.md` said "Three consequences"
above four bullets, and its ADR-0087 marker claimed "bullet 3 IS a
prescription" where bullet 3 is the read-seam bullet and the prescription is
bullet 4 (the `NOT NULL` / `storage.notNull` one). The marker is the auditable
artifact the gate prints verbatim, so a wrong internal pointer defeats its
purpose. Exactly one `adr-0087:` marker, same category and same argument.
Docblock and changeset prose only: no behaviour change, no schema move, no
test edit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Js5kTpTtxieBjPyScgxJ3
Landing note — the standing PASS WITH FINDINGS (5597570687, head
|
Fixes #16318
Clause-②: yes
Ruled C ∩ ④ (director seat, decision batch 86, 2026-09-08). One explicit per-field-type
physical-representation table lives in
packages/spec, and all three producers read it:SqlDriver.createColumn,os generate migration --format sql, and the typescript format.New tables only — no existing column is retyped, no migration is planned, no backfill runs.
The two measurements the ruling named as pre-work
Both were taken before any number was chosen. Every reading below carries a firing control in
the same run, and every count was read from the harness's own printed count, never from a pipe.
Every reading below is stated against the PR head
c5c2182f. The readings themselves wereexecuted at
c0d4ed6f9f; the section Patch rounds sincec0d4ed6f9fstates, commit bycommit, everything that landed after it and why none of it moves a number above. The live-dialect
cell added on this head re-executes the PostgreSQL and the MySQL halves in CI against the head
itself, so the table is no longer only a local reading.
1. The three-producer table, re-run on PostgreSQL 16.13
One object, seven plain numeric declarations, three producers (
initObjects;--format sqlviadb.raw; the typescript format imported and itsup(db)called), read back out ofinformation_schema.columns— withnumeric_precision/numeric_scale, which is the half theoriginal report's bare
data_typeread hid.BEFORE (
c930f8597, the branch point):AFTER, same command, same database:
⭐ The divergence is wider than the card reported: six of the seven were a THREE-way split,
not a two-way one.
table.decimal(name)with no arguments is knex'sdecimal(8, 2), so the twohalves of one command never agreed with each other either. The card's
data_typeread could notsee it.
2. The
DECIMAL(5,2)truncation — measured, and the inference is CORRECTEDThe order is explicit that this was an inference off the DDL literal and that a rounding or a
validating generator path re-opens the whole weighting. Executed on PostgreSQL 16.13:
(
33.336arrives as33.34); it does not truncate. The loss is silent either way.And nothing upstream prevents it — this is the half that decides whether the weighting re-opens.
Driven through
validateRecorditself:⇒ The generator path neither rounds nor validates. For a field that declares no
scaletheCOLUMN is the only thing deciding, and a narrow one silently alters data. The weighting that made
option A insufficient stands; only the word "truncate" was wrong.
record-validator.tsstates theplatform's own position on exactly this (#7501, maintainer ruling 2026-08-11): an over-scale value
"is refused the way an out-of-range one is; silent rounding is silently altering data".
summaryhas NO seam at all — it is platform-computed andvalidateRecord's type doorexcludes it — so for that member the column is the only guard there has ever been.
Where
65,30comes from — both numbers are dialect maxima, not tasteNine-value corpus, live PostgreSQL 16.13, written and read back through the driver's own pg type
parsing. Control:
12.5, a dyadic rational every candidate holds exactly — 0 of 5 lost it.real's three are the ones that matter:1234567.89reads back1234567.9andNumber.MAX_SAFE_INTEGERreads back9007199000000000. That is the money-fidelity defect thecard named, as a reading rather than an argument.
65and30are MySQL's documentedDECIMALmaxima — the binding constraint among the dialectsthis platform speaks (PostgreSQL's ceiling is 1000 digits, SQLite has none).
numericis NOT portable: measured through knex's compilers,decimal(name, null)compiles todecimalonpg, tofloatonbetter-sqlite3, and THROWS onmysql2("Specifying noprecision on decimal columns is not supported"). A stated pair is the only spelling all three
accept, which is what the ruling asked for.
The residual bound, stated rather than assumed. An exact decimal is bounded where a float is
not, and in BOTH directions. Downward, the column keeps 30 fractional digits, so a magnitude whose
significant digits run past the 30th decimal place loses the tail silently —
1.2345678901234567e-15stores as0.000000000000001234567890123457. Precision loss thereforeBEGINS around 1e-13 in absolute value, where a double's ~17 significant digits first reach past
the 30th decimal place, and is TOTAL below 1e-30, where nothing is left and the value rounds to
zero. Upward, magnitudes at or above 1e35 are REFUSED (the 35 integer digits that 65 - 30 leaves),
where
realkept about seven significant digits out to ~1e38. A refusal is loud and the roundingit replaces was not, and the sql format's
numeric(18,2)already refuses everything at or above1e16 today. The same bound is stated in the changeset and, in full, in the canonical home both
sql-driver.tsandos generate migrationpoint readers at:packages/spec/src/data/numeric-column-representation.ts.THE FOSSILS, quoted
SqlDriver.createColumn's float arm — the fossil for the SQLite affinity choice:The ADR-0113 reasoning at the end of
createColumn, for the nullability half:Neither fossil contradicts these instructions. Both are carried into the new arms verbatim rather
than summarised away.
SQLite, per type — the constraint the card raised, answered
knex 3.3.0 / better-sqlite3, compiled DDL and live storage class:
Per type, for every type moved out of the float arm:
number/currency/percent/slider/progress/summary— BYTE-IDENTICALSQLite DDL to the float arm they leave.
ColumnCompiler_SQLite3.prototype.decimalis theliteral
'float', the same stringfloatingresolves to. They keep REAL affinity, the fossil'sleak stays defeated, and SQLite applies no precision and no scale — so the exactness this table
buys is a PostgreSQL/MySQL property and SQLite behaves exactly as it does today.
rating— moves to INTEGER affinity.4is stored as the integer4rather than the real4.0, and4.5is still accepted as a REAL: SQLite refuses no fractional value, so nothingthis dialect accepts today stops being accepted. The refusal
ratinggains isPostgreSQL/MySQL-only.
The
table.stringcontrol in the same run still compiled tovarchar(255)and still storedtext:4.0, so "identical" above is a discriminating reading and not a constant.⭐ A blocking consequence nobody had measured: the READ path
Moving the driver's numeric columns to an exact decimal changes the JS type they read back as.
Measured on live PostgreSQL 16.13:
node-postgres parses
numericto a STRING, and mysql2 does the same forDECIMAL. Drivenend-to-end through
driver.create/driver.find, the first implementation returned 3 of 4numeric fields as strings — a wire-contract break, since
valueSchemaForgives the whole classz.number().finite().formatOutput'snumericFieldspass already exists and already repairs exactly this, but it satinside the SQLite-only arm, on a premise stated in
readPresentationKind's docblock: "The numericrepair stays SQLite-only: it exists for legacy TEXT-affinity columns, which no other dialect has."
This change falsified that premise, so the pass now runs on every dialect and the fossil
sentence is corrected in place rather than left standing. After that, end-to-end:
1234567.89is the discriminating value: on therealcolumn it read back1234567.9.The nullability half — landed HERE, not in a paired PR
Stated as the order requires. It lands in this PR because both generators' emitters are edited a
few lines apart from the numeric arms, and splitting them would put two halves of one file's
rewrite in two reviews. Both formats now take the physical
NOT NULLfromstorage.notNullandnever from
required. Driven on live PostgreSQL 16.13, four declaration shapes, three producers:This unblocks #16294 cause 1. That card's other two causes are not addressed here and stay
on it.
The "new tables only" bound, verified rather than asserted
A table built exactly as the pre-change driver built it (
realnumeric columns, rows in it) wasput in front of
detectManagedDriftalongside a control table carrying the stale-textual shapethe detector DOES report:
⇒ 0 drift findings for the four pre-existing
realnumeric columns, while the control firedtwice in the same run. Nothing here retypes an existing column, plans a migration, or starts
reporting drift over the difference — the additive sync only ever ADDS columns.
numeric field after this lands carries a
numeric(65,30)column beside its olderrealones.That is what "new columns only" means, and it is the shape the ruling chose — the alternative is
the migration of existing data it explicitly declined (「不考虑现有数据」).
Gaps that stay OPEN — stated, not silently assumed
currencycolumns are unknown. No deployment was surveyed.The triage seat's 「无拉动」 is absence of evidence, not evidence of absence, and nothing here
changes that.
(
packages/drivers/driver-sql/src/sql-driver-16318-numeric-representation-live-dialects.test.ts)drives a real MySQL and a real PostgreSQL under the
Temporal Conformance (live PG + MySQL)job, which sets both URLs and
OS_EXPECT_LIVE_DIALECT_MATRIX=1so an unavailable server is REDrather than skipped, and it pins the per-dialect
ratingdisposition (PostgreSQL REFUSES4.5;MySQL ROUNDS it to
5with no error). What remains unexecuted is a MySQL server on theauthoring box: the MySQL evidence is that CI job on the head, plus knex's compiled DDL and
MySQL's documented
DECIMALcaps.precision nor scale, so "the six members are unchanged there" is a statement about affinity and
DDL bytes — it is not a claim that SQLite gained exactness.
--format sqlremains a PostgreSQL-only claim (os generate migration's audit-stamp columns diverge from driver-sql — the generators emitNOT NULLwhere the driver emits nullable, and the SQL format emitsTIMESTAMPwhere both knex paths yieldtimestamptz#15521). Neither generator reproduces thedriver's dialect branching, and this PR does not change that.
Full file surface
Cross-lane by design — the type table cannot be split across two PRs without producing two
spellings of it. Read from the head itself:
git diff --stat origin/main...HEADatc5c2182f—16 files changed, 1421 insertions(+), 76 deletions(-). All 16, none elided:
⛔ No governed path is touched: nothing under
docs/adr/,.claude/,skills/,AGENTS.md,CLAUDE.mdorcontent/docs/releases/.generate-multiple-json-column.pin.test.ts— the VEHICLE changed, the subject did not. Thatpin is about
multiplenot deciding nullability;requiredwas merely how a NOT NULL was spelledwhen it was written. It now spells the constrained case with
storage.notNulland asserts therequired-only case is nullable BESIDE it, which is the half that would catch this change beingsilently reverted.
#16693 is in flight on adjacent text — left alone
Card #16693 has commits pushed against
packages/spec/src/conversions/registry.ts'sfield-required-notnull-explicitentry and theADR-0113 sentence in
sql-driver.ts. ⛔ Neither was edited here. This PR quotes the ADR-0113sentence and adds a separate
#16318note elsewhere in the file; the conversion registry is notin this diff at all. If that text moves under this branch, the two land independently.
Patch rounds since
c0d4ed6f9fThree commits sit between the head every reading above was executed on and the head this body now
pins. What each one did, and what it could move:
1be05800—merge: origin/main@ee2cb6b40. No conflict resolution touched a file ofthis PR's increment; the 16-file surface above is
origin/main...HEAD, so nothing main broughtis counted in it.
git merge-tree --write-tree origin/main HEADis clean and the API reportsmergeable: clean.034856799a— the documentation half, the live cell, and the read-seam scoping. Seven files:types.mdx,sortability.zod.tsand its generatedsortability.mdx,measure-result-type.ts(comment only), the new 281-line live-dialect cell,
sql-driver.ts(the newprotected numericValueFields, which NARROWS the every-dialect read coercion to the seven exact numerictypes and leaves the
integer/int/floataliases alone on the server dialects), and thechangeset. Of these only
numericValueFieldsis behaviour, and it narrows rather than widens —the readings above are all about the seven members, which it keeps.
c5c2182f— this round: docblock and changeset prose only, no behaviour, no test edit. Twofiles.
numeric-column-representation.ts's residual-bound section stated only that magnitudesbelow 1e-30 round to zero, while the shipped changeset states the 30-fractional-digit bound and
that loss BEGINS around 1e-13 in absolute value; the docblock is the canonical home both the
driver and the CLI point readers at, and it says of itself that the bound is "stated here so no
reader has to rediscover it", so it now states both ends in the changeset's own terms. In the
changeset, "Three consequences" sat above four bullets, and the ADR-0087 marker pointed at
bullet 3 (the read-seam bullet) where the prescription is bullet 4 (the
NOT NULLone) — themarker is the artifact the gate prints verbatim, so a wrong internal pointer defeats its purpose.
Exactly one
adr-0087:marker, same category and same argument.Verification
Red-before / green-after with the same command, both outputs quoted above (7 of 7 diverge → 0 of
7), plus:
NUMERIC_COLUMN_SCALEmutated 30 → 2 in the spec source, themutation proved on disk (blob hash moved off the HEAD blob; the anchor count moved 1→0 and
0→1), the pin run RED (exit 1, 1 failed / 4 passed), restored with
git checkout HEAD --, therestore proved byte-identical to the HEAD blob with an empty
git diff HEAD, and the restoredleg run GREEN (exit 0, 5 passed). Trap-guarded on EXIT/INT/TERM with an absolute path.
pnpm --filter @objectstack/driver-sql test— 162 files passed, 10 skipped; 2449 tests passed.pnpm --filter @objectstack/cli exec vitest run --project unit— 186 files, 2561 tests, allpassing after the pin above was updated (it was RED first, on exactly the assertion this change
reverses).
pnpm --filter @objectstack/cli exec vitest run --project integrationon the generator pins —36 tests; the new pin is integration-tier (it value-imports the driver) and runs 8 of them.
pnpm --filter @objectstack/spec test— 465 files passed, 1 skipped; 12969 tests passed. (Thenew pin is 5 of them.)
typecheckfor all three packages — exit 0.pnpm --filter @objectstack/spec check:generated— was red onapi-surfaceandexport-origins, regenerated with the two commands it named, and both are committed.Gate coverage was derived, not guessed —
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, all 83 run,then reconciled. The reconciliation line, verbatim:
81 of 83 exit 0, and 2 exit 3 — NOT MEASURED, never a pass:
pnpm check:dual-build-cjs-loads—PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/.It names 12 packages this diff does not touch (studio,client-react, the connectors, the plugins). It needs a whole-monorepopnpm build, which doesnot fit this box's foreground budget; CI builds everything and runs it there. Declared, not
skipped.
pnpm check:i18n-coverage— same cause, one layer out:os lintcould not loadexamples/app-showcase/objectstack.config.tsbecause@objectstack/connector-mcphas no buildoutput in this worktree. Its own text says "Nothing was compared… this result says NOTHING".
pnpm check:type-check-debt, first exited 3 with a V8FATAL ERROR: Ineffective mark-compacts near heap limitunder--max-old-space-size=4096on thisshared box — its own text says that is "NOT a pass and NOT a finding". Re-measured at 8192 it
exits 0. The pass above is that second run, not the OOM.
The gate list was re-derived against a freshly fetched
origin/mainafter the sweep: identical, 83families, none added and none dropped, so nothing newly landed upstream is owed.
The rounds after
c0d4ed6f9f, verified on this headThe sweep quoted above was run at
c0d4ed6f9fand PREDATES both later commits, so it is not areading about this head; CI on
034856799awas the authority for the code half (38 check runs, 30success / 4 skipped / 0 failed at the contract review's read time) and CI on
c5c2182fis theauthority now.
This round (
c5c2182f) is docblock and changeset prose only —git diff --stat 034856799a..HEADis 2 files, 15 insertions, 8 deletions; no schema, no behaviour, no test edit.The local run was NARROWED to the families that read those two files. The narrowing is declared
here rather than left implicit, and the whole farm remains CI's:
check:docsis the load-bearing one for a docblock edit, because one of the prior round's ownfindings was a generated page edited at the wrong end:
packages/spec's reference pages are builtfrom its
*.zod.tsmodules, this module is not one, and the gate re-derived all 228 generatedfiles from the head and found them in sync — so this docblock projects into no published page.
(It reads generated schema artifacts, so a fresh worktree fails it with
PREREQUISITE NOT METuntil
gen:schemahas run; that first reading is NOT MEASURED and the exit 0 above is the runthat had the prerequisite.
gen:schemaleft the tree clean, which is the second half of the samestatement: this edit moves no generated artifact.)
Both heavy runs went through this container's shared verify lock⚠️ Those wall-clock seconds are shared-box figures — the
(
OS_VERIFY_LOCK_SLOT=issue-16318); the VERDICT lines above are the lock's own printed verdicts,never a bare
$?read across a pipe.lock excludes other locked runs, not the unlocked gate scripts running beside them.
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon this headreports the tree is 45 commits behind
origin/main, with 15 of the files it derives fromchanged across that range — the derivation still exits 0 but is an answer about a tree nobody is
on. This branch is deliberately not re-merged in this round (the round is two prose files); the
merge is clean (
git merge-tree --write-tree origin/main HEADexits 0 againstorigin/main@
aa5152d1), and CI on the head derives its own list.Level
minoron all three packages — a widening takes at leastminor, andmajoris refused in thelaunch window, so breaking-ness rides the BREAKING banner in the changeset plus its ADR-0087
disposition (
not-required (no-migration-prescription): nothing an author writes is removed orrenamed, so there is no FROM → TO edit to prescribe).
packages/drivers/driver-sql/src/is aNESTED package dir, which the level axis cannot see (#16713) — it is graded
minorhere anyway,and that grading is the author's, not the gate's.
🤖 Generated with Claude Code
https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
Generated by Claude Code